Types and tools for Salesforce TypeScript development
What is this?
This is a simple TypeScript-oriented library developed for use in Salesforce TypeScript libraries, applications, and plugins consisting of two parts:
- A collection of commonly desired types.
- A collection of type-narrowing convenience functions for writing concise type-guards.
See the API documentation for more details on each of the utilities that ts-types
provides.
Why did we create it?
We were interested in developing with strict compiler settings in TypeScript. Among the sub-settings that comprise strict mode are strictNullChecks
, strictPropertyInitialization
, and noImplicitAny
. tslint
provides additional rules that further improve code quality and type correctness by discouraging explicit any
usages and other unsafe constructs such as some classes of unwarranted type assertions. Together, these settings have the potential to increase code quality substantially, reducing the frequency of certain classes of runtime errors typical in classical JavaScript applications. They also encourage the writing of clearer, more accurately typed code, which helps teams work together more effectively and more rapidly onboard new hires.
Of course, stricter compiler settings require developers to write more type-safe code -- or to work around the compiler's insistence on type-safety. Often this stricter style leads to more verbose code in the way of type declarations and type guards, and can require new and occasionally unfamiliar patterns to accomplish without subverting the compiler's enforcement of the type system (typically via the use of type assertions).
TypeScript provides both syntax and built-in types designed to help write well-typed code, but we can be more terse and concise by employing additional types and type-narrowing utility functions by leveraging language features like type predicates backed by sound runtime validation, simplified undefined
and null
checking, etc. That's where this library comes in.
How will it help me?
This library has its roots in solving the problem of how to handle untyped JSON data in a type-safe way. It was born when we added some basic type declarations to replace the unsafe any
type as a stand-in for JSON data with a type that could capture the range of data available in any JSON construct. This yielded the AnyJson
type, which is effectively a union of all primitive and collection JSON values. The type alone was not enough to make for convenient, type-guarded handling of JSON data, however. TypeScript supports a very elegant system of control flow analysis that will narrow the type of a variable in code after the compiler can prove the set of possible types of the variable has been reduced. Using type guards in your code improves its runtime type safety characteristics, makes it more readable, and provides richer typing information for IDEs. Type guards are implemented as conditional statements, however, and can quickly become noisy and make what was once terse JavaScript code expand into several lines of type checking. This library aimed to simplify the experience of reducing the amount of type guards needed to process a typed-JSON data structure by providing several convenience functions that help extract well-typed data from such JSON structures.
For example, look at the following typical untyped JSON processing in JavaScript:
JSON.parse(response.body).results.forEach(item => db.save(item.id, item));
Then a safe version in bare TypeScript using type guards:
const json = JSON.parse(response.body);
if (json === null && typeof json !== 'object') throw new Error('Unexpected json data type');
let results = json.results;
if (!Array.isArray(results)) results = [];
results.forEach(item => {
const id = item.id;
if (typeof id !== 'string') throw new Error('Unexpected item id data type');
db.save(id, item);
});
While that's pretty safe, it's also a mess to read and write. That's why this library is here to help!
const json = ensureJsonMap(JSON.parse(response.body));
const results = asJsonArray(json.results, []);
results.forEach(item => {
record = ensureJsonMap(record);
db.save(ensureString(record.id), record);
});
Removing the comments, we can shorten the above somewhat to achieve something not much more complex than the original example, but with robust type and null checking implemented:
asJsonArray(ensureJsonMap(JSON.parse(response.body)).results, []).forEach(item => {
const record = ensureJsonMap(item);
db.save(ensureString(record.id), record);
});
The ensure*
functions are used in this example since they will raise an error when the value being checked either does not exist or does not match the expected type. Additionally, and perhaps more importantly, the generic any
and AnyJson
types get progressively narrowed when using these functions to more specific types. Of course, you don't always want to raise an error when these conditions are not met, so alternative forms exist for each of the JSON data types that allow the types to be tested and narrowed -- see the is*
and as*
variants in the API documentation for testing and narrowing capabilities without additionally raising errors.
Beyond JSON
After a few iterations of working on the JSON support types and utilities, it became apparent that we needed other non-JSON types and functions that provide similar capabilities. Rather than create a new library for those, we instead grew the scope of this one to contain all of our commonly used types and narrowing functions.
Types
A small library of types is included to help write more concise TypeScript code. These types are in part designed to augment the standard types included with the TypeScript library. Please see the generated API documentation for the complete set of provided types. Here are a few of the most commonly used types:
- Optional<T>: An alias for the union type
T | undefined
. - NonOptional<T>: Subtracts
undefined
from a type T
, when T
includes undefined
as a union member. - Nullable<T>: An alias for the union type
Optional<T | null>
, or T | null | undefined
. NonNullable
is a TypeScript built-in that subtracts both null
and undefined
from a type T
with either as a union member. - Dictionary<T=unknown>: An alias for a
string
-indexed object
of the form { [key: string]: Optional<T> }
. - KeyOf<T>: An alias for the commonly needed yet verbose
Extract<keyof T, string>
. - AnyJson: A union type of all valid JSON values, equal to
JsonPrimitive | JsonCollection
. - JsonPrimitive: A union of all valid JSON primitive values, qual to
null | string | number| boolean
. - JsonMap: A dictionary of any valid JSON value, defined as
Dictionary<AnyJson>
. - JsonArray: An array of any valid JSON value, defined as
Array<AnyJson>
. - JsonCollection: A union of all JSON collection types, equal to
JsonMap | JsonArray
.
Narrowing functions
This library provides several categories of functions to help with safely narrowing variables of broadly typed variables, like unknown
or object
, to more specific types.
is*
The is*
suite of functions accept a variable of a broad type such as unknown
or object
and returns a boolean
type-predicate useful for narrowing the type in conditional scopes.
if (isString(value)) {
}
as*
The as*
suite of functions accept a variable of a broad type such as unknown
or object
and optionally returns a narrowed type after validating it with a runtime test. If the test is negative or if the value was not defined (i.e. undefined
or null
), undefined
is returned instead.
function upperFirst(s: Optional<string>): Optional<string> {
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
const name = upperFirst(asString(value));
ensure*
The ensure*
suite of functions narrow values' types to a definite value of the designated type, or raises an error if the value is undefined
or of an incompatible type.
try {
const s = ensureString(value);
} catch (err) {
}
has*
The has*
suite of functions both tests for the existence and type-compatibility of a given value and, if the runtime value check succeeds, narrows the type to a view of the original value's type intersected with the tested property (e.g. T & { [_ in K]: V }
where K
is the test property key and V
is the test property value type).
if (hasString(value, 'name')) {
if (hasArray(value, 'results')) {
} else if (hasInstance(value, 'error', Error)) {
}
}
get*
The get*
suite of functions search an unknown
target value for a given path. Search paths follow the same syntax as lodash
's get
, set
, at
, etc. These functions are more strictly typed, however, increasingly the likelihood that well-typed code stays well-typed as a function's control flow advances.
const response = {
start: 0,
length: 2,
results: [{ name: 'first' }, { name: 'second' }]
};
const nameOfFirst = getString(response, 'results[0].name');
coerce*
The coerce
suite of functions accept values of general types and narrow their types to JSON-specific values. They are named with the coerce
prefix to indicate that they do not perform an exhaustive runtime check of the entire data structure -- only shallow type checks are performed. As a result, only use these functions when you are confident that the broadly typed subject being coerced was derived from a JSON-compatible value. If you are unsure of an object's origins or contents but want to avoid runtime errors handling elements, see the to*
set of functions.
const response = coerceJsonMap(JSON.parse(await http.get('http://example.com/data.json').body));
to*
The to*
suite of functions is a fully type-safe version of the coerce*
functions for JSON narrowing, but at the expense of some runtime performance. Under the hood, the to*
functions perform a JSON-clone of their subject arguments, ensuring that the entire data structure is JSON compatible before returning the narrowed type.
const obj = {
name: 'example',
parse: function(s) {
return s.split(':');
}
};
const json = toJsonMap(obj);
object utilities
This suite of functions are used to iterate the keys, entries, and values of objects with some typing conveniences applied that are not present in their built-in counterparts (i.e. Object.keys
, Object.entries
, and Object.values
), but come with some caveats noted in their documentation. Typical uses include iterating over the properties of an object with more useful keyof
typings applied during the iterator bodies, and/or filtering out undefined
or null
values before invoking the iterator functions.
const pets: Dictionary<string> = {
fido: 'dog',
bill: 'cat',
fred: undefined
};
function logPet([name, type]: [string, string]) {
console.log('%s is a %s', name, type);
}
definiteEntriesOf(pets).forEach(logPet);
References
Another Salesforce TypeScript library, @salesforce/kit, builds on this library to add additional utilities. It includes additional JSON support, a lightweight replacement for some lodash
functions, and growing support for patterns used in other Salesforce CLI libraries and applications.